All files / src/app/collections/[id]/items/[itemNumber] page.tsx

88.88% Statements 16/18
52.38% Branches 11/21
100% Functions 4/4
88.23% Lines 15/17

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105                                              4x 4x 4x       4x   4x   3x   3x       4x       3x           3x 3x   3x     3x       6x                   3x                                                                      
import { getSession } from "@/lib/auth";
import { redirect } from "next/navigation";
import { getDb } from "@/lib/db";
import { requireCollectionAccess } from "@/lib/collection-access";
import { ItemController } from "@/server/api/items";
import { PhotoController } from "@/server/api/photos";
import { DecisionController } from "@/server/api/decisions";
import { SessionController } from "@/server/api/sessions";
import { AppLayout } from "@/components/layout";
import { trail } from "@/components/layout/breadcrumb-trails";
import { Card } from "@/components/ui";
import { ItemPhotoCarousel } from "@/features/items/ItemPhotoCarousel";
import { ItemPanelForm } from "@/features/items/ItemPanelForm";
import { InYourLifeChip } from "@/features/items/InYourLifeChip";
import { ItemOverflowMenu } from "@/features/items/ItemOverflowMenu";
import { deriveUsage } from "@/lib/derive-usage";
import { itemDisplayName, itemSubtitle } from "@/lib/items";
 
interface ItemDetailPageProps {
  params: Promise<{ id: string; itemNumber: string }>;
}
 
export default async function ItemDetailPage({ params }: ItemDetailPageProps) {
  const { id, itemNumber } = await params;
  const session = await getSession();
  Iif (!session?.user) {
    redirect("/login");
  }
 
  const db = getDb();
  // Admits only owner or member — and every member may write (items:write / photos:write).
  const collection = await requireCollectionAccess(db, id, session.user.id);
 
  const number = Number(itemNumber);
  const item =
    number > 0 && Number.isInteger(number)
      ? await ItemController.fromDb(db).getByCollectionAndNumber(collection.id, number)
      : null;
 
  Iif (!item) {
    redirect(`/collections/${collection.slug ?? collection.id}`);
  }
 
  const [photos, curatorEvents, curatorSessions] = await Promise.all([
    PhotoController.fromDb(db).findByItem(item.id),
    // Server-only: only the folded answer for this one item reaches the client.
    DecisionController.fromDb(db).listForCurator(collection.id, session.user.id),
    SessionController.fromDb(db).listByCollectionAndCurator(collection.id, session.user.id),
  ]);
  const usage = deriveUsage(curatorEvents, new Set([item.id])).get(item.id);
  const openSession = curatorSessions.find((s) => s.status === "active" || s.status === "paused");
  // findByItem has no ORDER BY: cover first, then upload order (spec §6).
  const orderedPhotos = [...photos]
    .sort(
      (a, b) =>
        Number(b.isHero) - Number(a.isHero) ||
        +new Date(a.createdAt) - +new Date(b.createdAt) ||
        (a.id < b.id ? -1 : 1),
    )
    .map((p) => ({
      id: p.id,
      itemId: item.id,
      collectionId: collection.id,
      rotation: p.rotation,
      updatedAt: p.updatedAt,
      alt: itemDisplayName(item),
      isHero: p.isHero,
    }));
 
  return (
    <AppLayout breadcrumbs={trail.item(collection, item)}>
      <div className="space-y-6">
        <header className="flex flex-wrap items-start justify-between gap-x-3">
          <div className="min-w-0 flex-1">
            <h1 className="text-3xl text-ink sm:text-4xl">{itemDisplayName(item)}</h1>
            <p className="mt-1 text-sm text-ink-soft">{itemSubtitle(item, new Date())}</p>
          </div>
          <ItemOverflowMenu
            itemId={item.id}
            listHref={`/collections/${collection.slug ?? collection.id}`}
            isOwner={collection.ownerId === session.user.id}
          />
        </header>
        <div className="grid gap-6 md:grid-cols-[3fr_2fr] md:items-start">
          <ItemPhotoCarousel
            photos={orderedPhotos}
            collectionId={collection.id}
            itemId={item.id}
            itemName={itemDisplayName(item)}
          />
          <Card variant="card" padding="lg" className="space-y-6">
            <InYourLifeChip
              collectionId={collection.id}
              itemId={item.id}
              sessionId={openSession?.id ?? null}
              initial={usage}
            />
            <ItemPanelForm item={item} collectionId={collection.id} />
          </Card>
        </div>
      </div>
    </AppLayout>
  );
}